iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
Modern Web

重新認識Vue 走過路過不要錯過系列 第 6

Vue 走過路過不要錯過 Day06 - computed:不只是「快取」這麼簡單

  • 分享至 

  • xImage
  •  

用 Vue 一陣子之後,大多數人對 computed 的印象是「會快取結果的函式」。這句話不算錯,但只講對了一半。computed 什麼時候重算、什麼時候不算、為什麼有時候明明資料變了畫面卻沒動,這些都要回到它的運作原理才講得清楚。

這篇會從基本用法開始,接著整理它的幾個核心特性,簡單拆解原理,最後列出實務上容易踩到的坑。


一、基本用法

<script setup>
import { ref, computed } from 'vue'

const price = ref(100)
const qty = ref(2)

const total = computed(() => price.value * qty.value)
</script>

<template>
  <p>總價:{{ total }}</p>
</template>

幾個基本觀念:

  • computed() 接收一個 getter 函式,回傳一個唯讀的 ref
  • <script> 中要用 total.value 取值;在 template 中會自動解包,直接寫 total 就好。
  • 它的定位是衍生資料:從現有的狀態「算出」另一份資料,而不是另外存一份。

二、核心特性

特性 1:依據「響應式依賴」快取

先修正一個常見的說法:computed 並不是「函式結果的快取」,而是依據它讀取到的響應式依賴來決定要不要重算

用 methods 對照最容易看出差別:

<script setup>
import { ref, computed } from 'vue'

const count = ref(1)

const doubleComputed = computed(() => {
  console.log('computed 執行')
  return count.value * 2
})

function doubleMethod() {
  console.log('method 執行')
  return count.value * 2
}
</script>

<template>
  <p>{{ doubleComputed }} {{ doubleComputed }} {{ doubleComputed }}</p>
  <p>{{ doubleMethod() }} {{ doubleMethod() }} {{ doubleMethod() }}</p>
</template>

打開 console 會看到:

  • computed 執行 只出現 1 次
  • method 執行 出現 3 次

只要 count 沒變,不論讀幾次 computed,都直接拿上次的結果。

這裡的關鍵字是響應式。非響應式的值就算改變,也不會觸發重算:

const price = ref(100)
let tax = 1.05 // 普通變數

const total = computed(() => price.value * tax)

price.value = 200 // ✅ 重算:price 是 ref,Vue 追蹤得到
tax = 1.1         // ❌ 不重算:Vue 根本不知道 tax 變了

特性 2:惰性求值(lazy)

computed 不會在依賴改變的當下馬上重算。它只做一件事:把自己標記為「過期」。等到下次有人讀取 .value,才真正執行 getter。

const count = ref(1)
const double = computed(() => {
  console.log('計算中')
  return count.value * 2
})

count.value = 2
count.value = 3
count.value = 4
// 到這裡為止,一次「計算中」都沒印

console.log(double.value) // 這時候才印「計算中」,結果是 8

連續改三次,只算一次。沒有人讀取的 computed,完全不會執行。

特性 3:依賴是「動態收集」的

computed 追蹤的是上一次執行時實際讀到的響應式資料,不是 getter 裡「寫到」的所有變數。

const isLogin = ref(false)
const user = ref({ name: 'Fabio' })

const message = computed(() => {
  if (!isLogin.value) return '請先登入'
  return `歡迎,${user.value.name}`
})

isLoginfalse 時,函式在第一個 return 就結束,根本沒讀到 user。所以這時候改 user.value.name,computed 不會重算。

這是刻意的設計:當下用不到的資料,本來就沒必要追蹤。等 isLogin 變成 true 重新執行後,user 才會被收進依賴裡。除錯時如果遇到「明明改了卻沒反應」,可以先想想是不是這個原因。

特性 4:值沒變,就不通知下游(Vue 3.4+)

從 Vue 3.4 開始,computed 重算後如果結果和上次相同,就不會通知依賴它的 effect 更新。

const count = ref(0)
const isEven = computed(() => count.value % 2 === 0)

watchEffect(() => console.log(isEven.value))

count.value = 2 // isEven 還是 true,watchEffect 不會再跑

不過這個比較用的是 Object.is。如果每次都回傳新的物件或陣列,參考不同,就一律視為改變:

// 每次都是新物件,下游每次都會更新
const info = computed(() => ({ isEven: count.value % 2 === 0 }))

3.4 之後,getter 可以透過參數拿到舊值,自行決定要不要沿用:

const info = computed((oldValue) => {
  const next = { isEven: count.value % 2 === 0 }
  if (oldValue && oldValue.isEven === next.isEven) return oldValue
  return next
})

三、可寫的 computed

computed 預設是唯讀的,直接賦值會在開發環境跳出警告。需要寫入時,要傳入 get / set

const firstName = ref('承哲')
const lastName = ref('陳')

const fullName = computed({
  get: () => `${lastName.value}${firstName.value}`,
  set: (val) => {
    lastName.value = val.slice(0, 1)
    firstName.value = val.slice(1)
  }
})

fullName.value = '王小明' // 觸發 set,改的是原始資料

要注意,setter 的工作是回頭修改原始資料,而不是修改 computed 本身。computed 永遠是從原始資料算出來的。實務上可寫 computed 最常用在搭配 v-model 的元件封裝。


四、原理拆解:一個 dirty flag 撐起快取與惰性

前面介紹 Proxy 與 reactive 時提過兩個核心動作:讀取時收集依賴(track),寫入時觸發更新(trigger)。computed 就是建立在這個機制上,再加一個「是否過期」的旗標。

下面是概念示意,不是 Vue 原始碼。實際實作從 3.4、3.5 以來改成版本號計數等更複雜的方式,但核心想法相同:

function computed(getter) {
  let value
  let dirty = true // 是否過期,一開始當然是過期的

  // 用 effect 包住 getter,讓裡面讀到的響應式資料被收集成依賴
  const runner = effect(getter, {
    lazy: true, // 先不要執行
    scheduler() {
      // 依賴改變時不重算,只標記過期,並通知「用到我的人」
      if (!dirty) {
        dirty = true
        trigger(obj, 'value')
      }
    }
  })

  const obj = {
    get value() {
      if (dirty) {
        value = runner() // 過期才真的重算
        dirty = false
      }
      track(obj, 'value') // 讓外面讀取我的 effect 也能追蹤到我
      return value
    }
  }

  return obj
}

對照前面的特性:

特性 對應的程式
快取 dirtyfalse 時直接回傳 value
惰性 lazy: true,而且 scheduler 只改旗標、不執行 getter
只追蹤響應式資料 依賴是靠 Proxy 的 get 攔截收集的,普通變數不會經過 Proxy
動態依賴 每次執行 runner() 都重新收集,沒讀到的就不在名單上

理解這段之後,第二節那幾個特性就不需要死背了。


五、容易疏忽的地方

1. getter 裡寫副作用

// ❌
const total = computed(() => {
  count.value++       // 修改其他狀態
  fetch('/api/log')   // 發請求
  return price.value * qty.value
})

從上面的原理可以知道,getter 什麼時候執行、執行幾次,都不是你能控制的(可能不執行,也可能被快取跳過)。把副作用放在這裡,行為會變得無法預測。副作用請交給 watch / watchEffect

2. 不小心改到原始陣列

sort()reverse()splice() 會直接修改原陣列:

// ❌ list 本身被排序了
const sorted = computed(() => list.value.sort((a, b) => a.price - b.price))

// ✅ 先複製
const sorted = computed(() => [...list.value].sort((a, b) => a.price - b.price))

// ✅ 或用 ES2023 不修改原陣列的方法
const sorted = computed(() => list.value.toSorted((a, b) => a.price - b.price))

這個 bug 很隱蔽,因為畫面看起來「是對的」,但原始資料已經被動過了。

3. 依賴非響應式的值

// ❌ 永遠停在第一次讀取的時間
const now = computed(() => Date.now())

Date.now()Math.random()localStorage、一般的 let 變數,Vue 都追蹤不到。需要即時更新時,要自己用 ref 搭配計時器或事件更新:

const now = ref(Date.now())
setInterval(() => (now.value = Date.now()), 1000)

4. 想寫成 async

// ❌ 拿到的是 Promise,不是資料
const user = computed(async () => await fetchUser(id.value))

computed 必須同步回傳值。非同步資料請用 watch 搭配 ref

const user = ref(null)
watch(id, async (newId) => {
  user.value = await fetchUser(newId)
}, { immediate: true })

或使用 VueUse 的 computedAsync

5. 想傳參數進去

computed 的 getter 不接收自訂參數(3.4 以後唯一的參數是舊值)。常見的變通寫法是回傳一個函式:

const getItemTotal = computed(() => (id) => {
  const item = list.value.find(i => i.id === id)
  return item.price * item.qty
})

getItemTotal.value(3)

但這樣被快取的是那個函式,而不是計算結果。每次呼叫 getItemTotal.value(3) 都會重新 find、重新相乘,效果和一般函式差不多。需要帶參數的運算,直接寫成一般函式通常會更清楚。

6. 解構後失去響應性

const state = reactive({ count: 1 })
const { count } = state // count 只是數字 1,和 state 斷了連結

const double = computed(() => count * 2) // ❌ 永遠是 2

改成直接讀 state.count,或用 toRefs(state) 保留連結。

補充:Vue 3.5 起,在 <script setup> 裡解構 defineProps 會由編譯器自動轉成 props.xxx,所以仍然保有響應性。但解構 reactive() 還是會失效,兩者不要混為一談。

7. 修改 computed 回傳的結果

const activeList = computed(() => list.value.filter(i => i.active))

activeList.value.push(newItem) // ❌ 下次重算就消失了

computed 的結果是衍生出來的快照,改它沒有意義。要改就改原始資料 list


六、computed、methods、watch 怎麼選

computed methods watch
有沒有快取 沒有,每次呼叫都執行 不適用
有沒有回傳值 沒有
能不能傳參數 不行 可以 不適用
能不能非同步 不行 可以 可以
適合用在 從狀態算出新資料 事件處理、需要參數的運算 資料變了之後要做某件事

判斷原則很簡單:要「得到一個值」用 computed,要「做一件事」用 watch


小結

  • computed 依據響應式依賴快取,非響應式的值改變不會觸發重算。
  • 它是惰性的,依賴改變時只標記過期,被讀取時才重算。
  • 依賴是動態收集的,只追蹤上一次執行時實際讀到的資料。
  • Vue 3.4 起,結果沒變就不通知下游,但新物件永遠視為改變。
  • getter 要保持純粹:不寫副作用、不修改原始資料、不做非同步。

記住「dirty flag + track / trigger」這個模型,大部分 computed 的行為都能自己推導出來。


上一篇
Vue 走過路過不要錯過 Day05 - 解構為什麼讓 reactive 失效?toRefs 到底在做什麼
下一篇
Vue 走過路過不要錯過 Day07 - watch 與 watchEffect:什麼時候該用哪一個
系列文
重新認識Vue 走過路過不要錯過9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言